查看原文
其他

单元测试与重构

郭峰 京东技术 2022-01-06

Tech


导读

本文通过讨论测试的必要性以及对比“蛋卷“和“金字塔”两种测试模型,得到越底层的测试应该写得越多的结论,从而得出单元测试的重要性。之后介绍了较为流行的测试驱动开发和如何写好代码,最后介绍了重构相关知识。

通过本文可对单元测试的重要性加深印象,对单元测试即是开发工作中的一个重要环节加以理解。并对如何提升代码质量和写好单元测试提供了扩展学习资料。




01谁在做测试


开发人员肯定对“测试”工作的必要性不会产生疑问,但是对于“测试”由谁来做这个问题,相信很多人的第一直觉会是“测试不就应该是由测试人员做的事吗”。

事实上,开发人员在交付测试之前都会进行一些基本的测试,确保提交的代码是无误的,尽管你可能不认为这个是在做“测试”工作。

作为测试人员,其实只能站在系统外部做功能特性的测试,而软件是由诸多内部单元和模块所构成的,外部测试只能保证功能的准确性,但其实很难覆盖所有的分支和流程。

试想一下,你购买的一部分汽车,所有的零部件都未经过测试,只有组装完成后由质检员试开一圈,然后交付给你,你会购买这部汽车吗?但是,在软件工程当中,这种场景时时刻刻就在身边发生。

重要概念:

软件开发的成本会随着软件开发阶段逐步增加,也就是说尽早发现问题的修复成本是最低的,能在需求阶段发现就不要拖到开发阶段,能在开发阶段发现的就不要拖到测试阶段,如果上线后才发现问题,那很可能会演变成一场生产事故。

重要思想:

质量内建(Build quality in),其思想大意为,产品质量不是检测出来的,而是从它诞生的那一刻起就已经在那了。对于软件开发来讲,内建要求开发人员做好软件开发每个环节,尽早预防,以降低缺陷出现后的修复成本,要减少对创可贴式的补丁(hotfix)的依赖。更为理想的情况是软件质量贯穿开发的全流程,从需求开始的每个环节将“测试”纳入考量,产品经理确定验收标准,开发人员交出开发者测试。

所以,对于开发人员来说,只有在Coding时把Test也做好,才有资格说交付了高质量的代码,即测试也是开发人员工作中不可分割的一部分。那些在说因为需求比较紧急,所以没来得及做测试的开发人员,有必要定义一下开发阶段的DoD了。



02测试模型


可以将测试分成以下几个部分:

1、关注最小程序模块的单元测试

2、将多个模块组合在一起的集成测试

3、将整个系统组合在一起的系统测试

以上这些不同类型的测试由上到下,覆盖面越来越高,那么这些不同的测试,开发人员应该怎么搭配更合适呢?

1. 蛋卷模型

一种方法认为,既然高层次的测试覆盖面广,那就多写高层测试,比如系统测试;对于高层次无法覆盖的场景,再由低层次的测试进行补充,比如单元测试;这样就形成了下面这种测试模型:

图一 蛋卷模型(图片源自于网络)

其实这就是很多团队目前的现状,这是一种费事费力的模型。

2.金字塔模型

相比于蛋卷模型,金字塔模型是目前的行业最佳实践。(该模型参考Martin Fowler 的博客TestPyramid)。

想要理解金字塔模型,就要理解不同层次测试间的差异,越是底层的测试,牵扯到相关内容越少,而高层测试则涉及面更广。比如单元测试,只关注一个单元,开发完成即可进行测试;而集成测试则是要把好几个单元组装再一起进行测试,测试通过的前提就是每个单元都正确;系统测试则更复杂,集成好所有模块和单元后,甚至还要维护好基础数据才能进行测试。另外,涉及的模块或单元越多,当其中一个发生变化时可能所有的高层测试都会牵涉其中,复杂度进一步提升,定位问题也会比较复杂。反观低层次的测试,因为涉及内容较少,更容易写测试,一旦出现问题,也比较容易定位。

图二 金字塔模型(图片源自于网络)

所以,测试金字塔的重点就是越底层的测试应该写得越多。那么为什么一些开发人员的单元测试写起来这么难?这个问题后面会有答案。



03测试驱动开发


什么时候写测试?

对于这个问题,大部分开发者会说“肯定是写完代码之后再写测试”,这么说没有错,但是既然测试是程序员要做的工作之一,那么能不能先写测试再写代码呢?开发者脑海中肯定是浮现了一个词TDD(Test Driven Development),TDD 就是先写测试后写代码,然而这个理解是错误的,先写测试后写代码的实践应该是“测试先行开发(Test Frist Development)”,虽然只差了一个词“驱动”,那这两个词之间有什么区别呢?想要理解测试驱动开发,就要理解什么是“驱动”,也就是TFD和TDD的差别。

图三 测试驱动开发

如上图,测试先行开发和测试驱动开发,在第一步先写测试,第二步写代码使测试通过,这两步是一样的,区别点在于第三部分“重构”,也就是说测试驱动开发在开发完成,测试跑通之后,还需要再次回到代码上“重构”,因为刚刚只是让代码跑起来了,设计上还有可改之处,新增代码往往存在很多“坏味道”,而重构则是消除坏味道的手段,一旦有了测试,就可以大胆的进行重构,因为任何错误都可以很容易的被捕捉到。

平时总能听到“这段代码是有问题,但是现在不敢改”、“这段代码不敢动,所以复制了一份在此基础上进行增改”等等这样的话,这些问题总归来讲,就是没有做好单元测试。

测试驱动设计

很多开发人员排斥单元测试,常见有两个理由:需要“额外”的工作量,时间不够;代码太多不好测。第一点,上面已经提过,测试就是开发人员工作的一部分第二点,细想一下会发现,其实说的是代码已经写完了,需要后补测试。

如果把“先写代码,后补测试”转换成:先写测试,写代码是为了让测试通过,写出的代码天然具备可测性,是不是就变得简单了呢?

在实际开发工作中,经常能见到长达100行及以上的函数/方法,这种代码绝大部分开发者会说不具备可测性。如果写代码时时刻想着可测性,是为了让测试通过,开发者再写这么长行数的代码都难。了解编写可测试代码的思路,即便不做 TDD,依然对改善软件设计有着至关重要的作用。所以,写代码之前,请先想想怎么测。

至于如何写好代码,可参考《代码整洁之道》。



04简单测试


面遗留了一个问题,为什么开发人员的测试写起来这么难呢?这个问题和开发者写出来长达100行的方法有着直接的关联,因为它太过于复杂了。只有将复杂的测试拆分成简单的测试,测试才有可能做好。

《代码整洁之道》有个很重要的原则:只做一件事。函数、类、模块,都全神贯注一件事。软件设计的许多原则最终都会归结为这句警句 。当定义的方法明明是getXXX,却改变了入参的属性;当定义方法时即有返回值,又改变了入参时;当在一个for循环里,改变两个值.....都在违反这一重要的原则。

既然测试也是用代码写的,那么如何保证测试代码的准确性呢?只有一个方法:把测试写简单,简单到一目了然,不需要证明它的正确性。

一种测试常见的坏味道是没有断言!这种测试就从来没失败过,一看代码竟然是print,这种测试最多也就能证明你曾经debug过这段代码。另一种是有断言,通常是assert 不等于0,true/false一类,看似没问题,但是如果真失败了,需要把调用代码读一遍,甚至debug才能定位到错误。还有一类测试,只能在编写测试时正常执行,后续其他开发人员将代码clone下来,无论如何也不能再次正常运行。

《单元测试之道》中总结道,好的测试应该遵循A-TRIP原则:

  • Automatic,自动化

  • Thorough,全面,应该尽可能用测试覆盖各种场景

  • Repeatable,可重复的

  • Independent,独立的

  • Professional,专业的

另外,关于如何测试,也有个[Right]-BICEP缩写:

  • Right – Are the results right? 结果是否正确?

  • B – are all the boundary conditions correct? 所有边界条件都是正确的么?

  • I – can you check the inverse relationships? 能否检查一下反向关联?

  • C – can you cross-check results using other means? 能够使用其他手段交叉检查一下结果?

  • E – can you  force error conditions to happen? 是否可以强制错误条件产生?

  • P – are performance  characteristics within bounds? 是否满足性能要求?

其中边界测试有个CORRECT的缩写:

  • Conformance(一致性):值是否和预期一致。可以理解为当输入并不是预期的标准数据时,被测试方法是否可以正确输出预期结果(或抛出异常)。

  • Ordering(顺序性):值是否像应该的那样是无序或有序的。

  • Range(区间性):值是否位于合理的最小值和最大值之间。

  • Reference(依赖性):代码是否引用了一些不在代码本身控制范围之内的外部资源,当这些外部资源存在或不存在、满足或不满足时,代码是否可以产生相应的预期结果。

  • Existence(存在性):值是否存在(是否为null、0、在一个集合中)。测试方法是否可以处理值不存在的情况。

  • Cardinatity(基数性):是否恰好有足够的值。这里的基数指的是计数,测试方法是否可以正确计数,并检查最后的计数值。

  • Time(相对或绝对时间性):所有事情的发生是否是有序的、是否在正确的时刻、是否恰好及时。与时间相关问题有:相对时间(时间上的顺序)、绝对时间(消耗的时间和钟表上的时间)、并发问题。例如:方法调用的时间顺序、代码超时、不同的本地时间、多线程同步等。



05重构


"Any fool can write code that a computer can understand. Good programmers write code that humans can understand."

任何傻瓜都可以编写计算机可以理解的代码。优秀的程序员会编写人类可以理解的代码。

本质上讲,重构是为了改进已有软件/代码的设计,使软件更易于维护;也就是说,在不改变系统/代码的外部行为前提下,以改进其内部结构的方式改变软件系统的过程,使其更易于理解且修改成本更低。

1、何时重构

《重构》中提到的三次法则,大意为:事不过三,三则重构。结合到日常开发工作中,可以在以下几个节点进行:

  • 添加功能时一并重构

  • 修复BUG时一并重构

  • 代码评审时一并重构

是不是有些情况,也不适合重构?通常来讲,如果重构的现有代码过于混乱,重构的成本过高,甚至重来要比重构还要容易则不再适合重构。另外,应尽量避免在临近最后时间点时进行重构,以免推迟计划,这种情况更适合将重构当成一项新的任务进行。

2、常见坏味道

  • 重复代码

  • 大方法

  • 大类

  • 方法参数列表过长

对于如何做好重构,可参考Martin Fowler著作《重构》。



推荐阅读大促千万级流量来袭前,我们都在做什么?
Flutter For Web实践
HBase在人资数据预处理平台中的实践配运基础数据缓存瘦身实践

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存